跳到主要内容

Java 并发编程-volatile关键字

volatile 关键字是什么

参考资料 你应该知道的 volatile 关键字

volatile主要就下面两个作用

1、使用 volatile 关键字来保证可见性,即一个线程修改了某个变量的值后,这新值对其他线程来说是立即可见的。

2、防止指令重排序。

即:当一个变量被 volatile 修饰时,任何线程对它的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。

内存可见性

public class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; // step 1
flag = true; // step 2
}

public void reader() {
if (flag) { // step 3
System.out.println(a); // step 4
}
}
}

在这段代码里,使用 volatile 关键字修饰了一个 boolean 类型的变量 flag。

所谓内存可见性,指的是当一个线程对 volatile 修饰的变量进行写操作(比如step 2)时,JMM 会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对 volatile 修饰的变量进行读操作(比如step 3)时,JMM 会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。

假设在时间线上,线程 A 先执行方法 writer 方法,线程 B 后执行 reader 方法。那必然会有下图:

image.png

而如果 flag 变量没有用 volatile 修饰,在 step 2,线程 A 的本地内存里面的变量就不会立即更新到主内存,那随后线程 B 也同样不会去主内存拿最新的值,仍然使用线程 B 本地内存缓存的变量的值 a = 0,flag = false

内存可见性问题案例

案例一

public class Temp {

// 创建一个静态变量,用来当共享变量
static int i = 3;

public static void main(String[] args) throws InterruptedException {
new Thread(()-> {
for(;;) {
// 当 i != 3就跳出循环
if(i!=3) break;
}
System.out.println("子线程结束");
}).start();

TimeUnit.SECONDS.sleep(1); // 确保子线程先执行

// 在主线程让 i 的值为4,按照正常思路子线程应该结束循环,
// 但实际上因为多线程的变量都在自己工作内存里面,子线程无法感知到这个变量已经改变,
// 所以子线程依旧在死循环
i = 4;
System.out.println("主线程已经把 i 改成:" + i);
}
}

所以得把这个 i 改成 volatile

static volatile int i = 3;

案例二

public class Temp {
static boolean ready;
static int number;

// 为了让代码更简洁,这里把睡眠方法提取出来,之所以要加这个方法是为了确保两个线程是 “同时” 执行的
static void sleep() {
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
// 读线程
new Thread(() -> {
sleep();
if (!ready) {
System.out.println("当前的 ready 为:" + ready);
}

System.out.println(number);
}).start();

// 写线程
new Thread(() -> {
sleep();
number = 100;
ready = true;
}).start();
}
}

从直观上理解,这段程序应该只会输出 100,ready 的值是不会打印出来的。实际上,如果多次执行上面代码的话,可能会出现多种不同的结果,如下输出的三种结果

100
----------------------------------
当前的 ready 为:true
100
----------------------------------
当前的 ready 为:false
100

volatile 无法保证原子性

volatile 只能保证读写的原子性,但是像 i++ 这种复合操作是无法保证的

public class Temp {

private static volatile int count = 0; //使用 volatile 修饰基本数据内存不能保证原子性

public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 10000; i++) {
count++;
//count.incrementAndGet() ;
}
};

Thread t1 = new Thread(runnable, "t1");
Thread t2 = new Thread(runnable, "t2");
t1.start();
t2.start();

Thread.sleep(1000);
// 最终的结果是少于 20000 的
System.out.println("最终Count=" + count);
}
}

这是因为虽然 volatile 保证了内存可见性,每个线程拿到的值都是最新值,但 count++ 这个操作并不是原子的,这里面涉及到获取值、自增、赋值的操作并不能同时完成。

所以 volatile 保证的是时效性而不是原子性

什么是重排序

参考资料 第七章 重排序与happens-before

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

为什么指令重排序可以提高性能?

简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术 产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。

但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。

分析一下下面这个代码的执行情况:

a = b + c;
d = e - f;

先加载 b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行 add(b,c) 的时候,需要等待 b、c 装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。

为了减少这个停顿,我们可以先加载 e 和 f,然后再去加载 add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然 add(b,c) 需要停顿,那还不如去做一些有意义的事情。

综上所述,指令重排对于提高 CPU 处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。

指令重排一般分为以下三种:

编译器优化重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令并行重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

内存系统重排

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

volatile 防止指令重排

内存可见性只是 volatile 的其中一个语义,它还可以防止 JVM 进行指令重排优化。

如下的例子,要避免 JVM 对操作 1 和 2 进行重排,如果重排了可能会导致线程 t2 操作未被初始化的 Map集合

public class Temp {

// 设置这个 flag 是希望在初始化 Map 后再执行线程 t2,因此要避免重排
private static Map<String, String> map;
// 其实这里 volatile还有个作用,可以保证变量的时效性
private static volatile boolean initMapFlag = false;

public static void main(String[] args) {

Thread t1 = new Thread(() -> {
// 假设这里执行一个耗时的操作,又因为 value 和 flag 没有直接的依赖性,
// 所以这里 value 和 flag的顺序可能会被重排,因此要给 initMapFlag 加 volatile
map = new HashMap<>(16); // 1
initMapFlag = true; // 2

}, "t1");

Thread t2 = new Thread(() -> {
while (!initMapFlag) {
// 这个方法内部就是一个空方法,它会对这种自旋锁的情况进行内部优化
// (JVM 层面的,就是暗示 CPU 这个地方在等待,你可以先去执行别的事情了)
Thread.onSpinWait();
}

// 这里的操作是需要在 Map 被初始化后才能执行的
map.put("张三", "班长");
System.out.println(map.get("张三"));
}, "t2");

t1.start();
t2.start();
}
}

内存屏障(Memory Barrier)

参考资料 内存屏障及其在 JVM 内的应用(下)

TODO: 之后学习了 JVM 后再来补充